Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 390e25efcbfdccb6d275eb544aec4fcc07fdca99


Parents : b3303e1
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-30T11:48:35-05:00

feat(electron): implement backend process management and recovery features

Changes
Diff

diff --git a/electron/backendProcess.js b/electron/backendProcess.js
new file mode 100644
index 00000000..47b63d15
--- /dev/null
+++ b/electron/backendProcess.js
@@ -0,0 +1,236 @@
+const path = require("node:path");
+const { spawn: defaultSpawn } = require("child_process");
+
+const { verifyBackendIntegrity } = require("./backendIntegrity");
+
+const LOG_LINE_CAP = 100;
+
+function createInitialRuntimeState() {
+ return {
+ started: false,
+ running: false,
+ pid: null,
+ lastExitCode: null,
+ lastError: "",
+ lastEventAt: null,
+ };
+}
+
+function createBackendProcessManager(deps) {
+ const {
+ log,
+ getDefaultStorageDir,
+ getDefaultReticulumConfigDir,
+ getMainWindowPageKind,
+ notifyRenderer,
+ showCrashPage,
+ spawn: spawnFn = defaultSpawn,
+ } = deps;
+
+ let childProcess = null;
+ let runtimeState = createInitialRuntimeState();
+ let logBuffers = { stdout: [], stderr: [] };
+ let lastCrash = null;
+ let resolvedExePath = null;
+ let userProvidedArguments = [];
+
+ function isRunning() {
+ return !!childProcess && childProcess.exitCode === null && childProcess.signalCode === null;
+ }
+
+ function getRuntimeState() {
+ return {
+ ...runtimeState,
+ running: isRunning() && runtimeState.started,
+ };
+ }
+
+ function pushLogLine(buffer, line) {
+ buffer.push(line);
+ if (buffer.length > LOG_LINE_CAP) {
+ buffer.shift();
+ }
+ }
+
+ function getJoinedLogs() {
+ return {
+ stdout: logBuffers.stdout.join(""),
+ stderr: logBuffers.stderr.join(""),
+ };
+ }
+
+ function getLastCrash() {
+ return lastCrash;
+ }
+
+ function setUserProvidedArguments(args) {
+ userProvidedArguments = Array.isArray(args) ? args : [];
+ }
+
+ function resolveExecutablePath(findExePath) {
+ resolvedExePath = findExePath();
+ return resolvedExePath;
+ }
+
+ function attachChildHandlers(proc) {
+ logBuffers = { stdout: [], stderr: [] };
+
+ proc.stdout.setEncoding("utf8");
+ proc.stdout.on("data", (data) => {
+ const text = data.toString();
+ log(text);
+ pushLogLine(logBuffers.stdout, text);
+ });
+
+ proc.stderr.setEncoding("utf8");
+ proc.stderr.on("data", (data) => {
+ const text = data.toString();
+ log(text);
+ pushLogLine(logBuffers.stderr, text);
+ });
+
+ proc.on("error", (error) => {
+ log(error);
+ runtimeState.lastError = error && error.message ? error.message : String(error);
+ runtimeState.lastEventAt = Date.now();
+ });
+
+ proc.on("exit", async (code) => {
+ runtimeState.running = false;
+ runtimeState.lastExitCode = code;
+ runtimeState.lastEventAt = Date.now();
+ childProcess = null;
+
+ if (code == null || deps.isQuiting()) {
+ return;
+ }
+
+ const logs = getJoinedLogs();
+ lastCrash = {
+ code,
+ stdout: logs.stdout,
+ stderr: logs.stderr,
+ at: Date.now(),
+ };
+
+ notifyRenderer("backend-process-exited", { code, at: lastCrash.at });
+
+ const page = getMainWindowPageKind();
+ if (page === "loading" || page === "app") {
+ return;
+ }
+
+ if (page === "crash") {
+ return;
+ }
+
+ await showCrashPage(lastCrash);
+ });
+ }
+
+ async function spawnBackend(exePath, integrityStatusRef) {
+ if (!exePath) {
+ throw new Error("Backend executable path is not set.");
+ }
+ if (isRunning()) {
+ return { ok: true, alreadyRunning: true };
+ }
+
+ resolvedExePath = exePath;
+ const exeDir = path.dirname(exePath);
+ integrityStatusRef.backend = verifyBackendIntegrity(exeDir);
+ if (
+ integrityStatusRef.backend.ok &&
+ integrityStatusRef.backend.issues.length === 1 &&
+ integrityStatusRef.backend.issues[0] === "Manifest missing"
+ ) {
+ log("Backend integrity manifest missing, skipping check.");
+ }
+ if (!integrityStatusRef.backend.ok) {
+ log(
+ `INTEGRITY WARNING: Backend tampering detected! Issues: ${integrityStatusRef.backend.issues.join(", ")}`
+ );
+ }
+
+ const requiredArguments = ["--headless", "--port", "9337"];
+ if (!userProvidedArguments.includes("--reticulum-config-dir")) {
+ requiredArguments.push("--reticulum-config-dir", getDefaultReticulumConfigDir());
+ }
+ if (!userProvidedArguments.includes("--storage-dir")) {
+ requiredArguments.push("--storage-dir", getDefaultStorageDir());
+ }
+
+ const proc = spawnFn(exePath, [...requiredArguments, ...userProvidedArguments]);
+ if (!proc || !proc.pid) {
+ throw new Error("Failed to start backend process (no PID).");
+ }
+
+ childProcess = proc;
+ runtimeState = {
+ started: true,
+ running: true,
+ pid: proc.pid,
+ lastExitCode: null,
+ lastError: "",
+ lastEventAt: Date.now(),
+ };
+ attachChildHandlers(proc);
+ return { ok: true, pid: proc.pid };
+ }
+
+ function getChildProcess() {
+ return childProcess;
+ }
+
+ function killChild(signal) {
+ if (!childProcess) {
+ return;
+ }
+ if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
+ return;
+ }
+ childProcess.kill(signal);
+ }
+
+ async function restartBackend(integrityStatusRef) {
+ if (!resolvedExePath) {
+ return { ok: false, error: "Backend executable is not configured." };
+ }
+ if (isRunning()) {
+ return { ok: false, error: "Backend is already running." };
+ }
+ try {
+ const result = await spawnBackend(resolvedExePath, integrityStatusRef);
+ return { ok: true, pid: result.pid };
+ } catch (error) {
+ return { ok: false, error: error && error.message ? error.message : String(error) };
+ }
+ }
+
+ async function openCrashReport(showCrashPageFn) {
+ if (!lastCrash) {
+ return { ok: false, error: "No backend crash report is available." };
+ }
+ await showCrashPageFn(lastCrash);
+ return { ok: true };
+ }
+
+ return {
+ createInitialRuntimeState,
+ setUserProvidedArguments,
+ resolveExecutablePath,
+ spawnBackend,
+ restartBackend,
+ openCrashReport,
+ getRuntimeState,
+ getLastCrash,
+ getChildProcess,
+ isRunning,
+ killChild,
+ getJoinedLogs,
+ };
+}
+
+module.exports = {
+ createBackendProcessManager,
+};

diff --git a/electron/main.js b/electron/main.js
index 9b36fe09..d12ffae9 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -13,11 +13,10 @@ const {
clipboard,
} = require("electron");
const electronPrompt = require("electron-prompt");
-const { spawn } = require("child_process");
const fs = require("fs");
const path = require("node:path");
-const { verifyBackendIntegrity } = require("./backendIntegrity");
+const { createBackendProcessManager } = require("./backendProcess");
const { getUserProvidedArguments, formatRenderProcessGoneDetails, isLocalBackendUrl } = require("./mainHelpers");
const { isAllowedShellPath } = require("./shellPathGuard");
const { normalizeExternalUrlForOpen } = require("./safeExternalUrl");
@@ -45,16 +44,8 @@ var activePowerSaveBlockerId = null;
// track if we are actually quiting
var isQuiting = false;
-// remember child process for exe so we can kill it when app exits
-var exeChildProcess = null;
-var backendRuntimeState = {
- started: false,
- running: false,
- pid: null,
- lastExitCode: null,
- lastError: "",
- lastEventAt: null,
-};
+// backend child process (managed by backendProcess.js)
+var backendManager = null;
// store integrity status
var integrityStatus = {
@@ -187,15 +178,20 @@ ipcMain.handle("backend-http-only", () => {
});
ipcMain.handle("backend-runtime-state", () => {
- const isRunning =
- !!exeChildProcess &&
- exeChildProcess.exitCode === null &&
- exeChildProcess.signalCode === null &&
- backendRuntimeState.started;
- return {
- ...backendRuntimeState,
- running: isRunning,
- };
+ return getBackendManager().getRuntimeState();
+});
+
+ipcMain.handle("restart-backend", async () => {
+ return await getBackendManager().restartBackend(integrityStatus);
+});
+
+ipcMain.handle("open-backend-crash-report", async () => {
+ const lastCrash = getBackendManager().getLastCrash();
+ if (!lastCrash) {
+ return { ok: false, error: "No backend crash report is available." };
+ }
+ await loadBackendCrashPage(lastCrash);
+ return { ok: true };
});
// add support for showing an alert window via ipc
@@ -462,6 +458,68 @@ function getAppIconPath() {
return fs.existsSync(iconPath) ? iconPath : fallbackIconPath;
}
+function getMainWindowPageKind() {
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ return "none";
+ }
+ const url = mainWindow.webContents.getURL();
+ if (url.includes("loading.html")) {
+ return "loading";
+ }
+ if (url.includes("crash.html")) {
+ return "crash";
+ }
+ if (isLocalBackendUrl(url)) {
+ return "app";
+ }
+ return "other";
+}
+
+async function loadBackendCrashPage(crash) {
+ const stdoutBase64 = Buffer.from((crash && crash.stdout) || "").toString("base64");
+ const stderrBase64 = Buffer.from((crash && crash.stderr) || "").toString("base64");
+ const code = crash && crash.code != null ? String(crash.code) : "";
+
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ await dialog.showMessageBox({
+ type: "error",
+ title: "MeshChatX Crashed",
+ message: `Backend exited with code: ${code}`,
+ });
+ app.quit();
+ return;
+ }
+
+ mainWindow.show();
+ mainWindow.focus();
+ await mainWindow.loadFile(path.join(__dirname, "crash.html"), {
+ query: {
+ code: code,
+ stdout: stdoutBase64,
+ stderr: stderrBase64,
+ },
+ });
+}
+
+function getBackendManager() {
+ if (!backendManager) {
+ backendManager = createBackendProcessManager({
+ log,
+ getDefaultStorageDir,
+ getDefaultReticulumConfigDir,
+ getMainWindowPageKind,
+ isQuiting: () => quitInitiated,
+ notifyRenderer: (channel, payload) => {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send(channel, payload);
+ }
+ },
+ showCrashPage: loadBackendCrashPage,
+ });
+ }
+ return backendManager;
+}
+
function createTray() {
tray = new Tray(getAppIconPath());
const contextMenu = Menu.buildFromTemplate([
@@ -724,131 +782,10 @@ app.whenReady().then(async () => {
log(`Found executable at: ${exe}`);
- // Verify backend integrity before spawning
- const exeDir = path.dirname(exe);
- integrityStatus.backend = verifyBackendIntegrity(exeDir);
- if (
- integrityStatus.backend.ok &&
- integrityStatus.backend.issues.length === 1 &&
- integrityStatus.backend.issues[0] === "Manifest missing"
- ) {
- log("Backend integrity manifest missing, skipping check.");
- }
- if (!integrityStatus.backend.ok) {
- log(`INTEGRITY WARNING: Backend tampering detected! Issues: ${integrityStatus.backend.issues.join(", ")}`);
- }
-
+ const manager = getBackendManager();
+ manager.setUserProvidedArguments(userProvidedArguments);
try {
- // arguments we always want to pass in
- const requiredArguments = [
- "--headless", // reticulum meshchatx usually launches default web browser, we don't want this when using electron
- "--port",
- "9337",
- // '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog
- ];
-
- // if user didn't provide reticulum config dir, we should provide it
- if (!userProvidedArguments.includes("--reticulum-config-dir")) {
- requiredArguments.push("--reticulum-config-dir", getDefaultReticulumConfigDir());
- }
-
- // if user didn't provide storage dir, we should provide it
- if (!userProvidedArguments.includes("--storage-dir")) {
- requiredArguments.push("--storage-dir", getDefaultStorageDir());
- }
-
- // spawn executable
- exeChildProcess = spawn(exe, [
- ...requiredArguments, // always provide required arguments
- ...userProvidedArguments, // also include any user provided arguments
- ]);
-
- if (!exeChildProcess || !exeChildProcess.pid) {
- throw new Error("Failed to start backend process (no PID).");
- }
- backendRuntimeState = {
- started: true,
- running: true,
- pid: exeChildProcess.pid,
- lastExitCode: null,
- lastError: "",
- lastEventAt: Date.now(),
- };
-
- // log stdout
- var stdoutLines = [];
- exeChildProcess.stdout.setEncoding("utf8");
- exeChildProcess.stdout.on("data", function (data) {
- // log
- log(data.toString());
-
- // keep track of last 100 stdout lines
- stdoutLines.push(data.toString());
- if (stdoutLines.length > 100) {
- stdoutLines.shift();
- }
- });
-
- // log stderr
- var stderrLines = [];
- exeChildProcess.stderr.setEncoding("utf8");
- exeChildProcess.stderr.on("data", function (data) {
- // log
- log(data.toString());
-
- // keep track of last 100 stderr lines
- stderrLines.push(data.toString());
- if (stderrLines.length > 100) {
- stderrLines.shift();
- }
- });
-
- // log errors
- exeChildProcess.on("error", function (error) {
- log(error);
- backendRuntimeState.lastError = error && error.message ? error.message : String(error);
- backendRuntimeState.lastEventAt = Date.now();
- });
-
- // quit electron app if exe dies
- exeChildProcess.on("exit", async function (code) {
- backendRuntimeState.running = false;
- backendRuntimeState.lastExitCode = code;
- backendRuntimeState.lastEventAt = Date.now();
- // if no exit code provided, we wanted exit to happen, so do nothing
- if (code == null) {
- return;
- }
-
- // show crash log
- const stdout = stdoutLines.join("");
- const stderr = stderrLines.join("");
-
- // Base64 encode for safe URL passing
- const stdoutBase64 = Buffer.from(stdout).toString("base64");
- const stderrBase64 = Buffer.from(stderr).toString("base64");
-
- // Load crash page if main window exists
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.show(); // Ensure visible
- mainWindow.focus();
- await mainWindow.loadFile(path.join(__dirname, "crash.html"), {
- query: {
- code: code.toString(),
- stdout: stdoutBase64,
- stderr: stderrBase64,
- },
- });
- } else {
- // Fallback for cases where window is gone
- await dialog.showMessageBox({
- type: "error",
- title: "MeshChatX Crashed",
- message: `Backend exited with code: ${code}\n\nSTDOUT: ${stdout.slice(-500)}\n\nSTDERR: ${stderr.slice(-500)}`,
- });
- app.quit();
- }
- });
+ await manager.spawnBackend(exe, integrityStatus);
} catch (e) {
log(e);
}
@@ -869,6 +806,7 @@ function quit() {
}
quitInitiated = true;
+ const exeChildProcess = getBackendManager().getChildProcess();
if (!exeChildProcess) {
app.quit();
return;
@@ -878,11 +816,11 @@ function quit() {
return;
}
try {
- exeChildProcess.kill("SIGTERM");
+ getBackendManager().killChild("SIGTERM");
} catch (e) {
log(e);
try {
- exeChildProcess.kill("SIGKILL");
+ getBackendManager().killChild("SIGKILL");
} catch (e2) {
log(e2);
}
@@ -892,8 +830,9 @@ function quit() {
const timeoutMs = 5000;
quitTimeoutId = setTimeout(() => {
try {
- if (exeChildProcess && exeChildProcess.exitCode === null && exeChildProcess.signalCode === null) {
- exeChildProcess.kill("SIGKILL");
+ const proc = getBackendManager().getChildProcess();
+ if (proc && proc.exitCode === null && proc.signalCode === null) {
+ getBackendManager().killChild("SIGKILL");
}
} catch (e) {
log(e);

diff --git a/electron/preload.js b/electron/preload.js
index cb4315c8..eda33b85 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -99,4 +99,16 @@ contextBridge.exposeInMainWorld("electron", {
backendRuntimeState: async function () {
return await ipcRenderer.invoke("backend-runtime-state");
},
+ restartBackend: async function () {
+ return await ipcRenderer.invoke("restart-backend");
+ },
+ openBackendCrashReport: async function () {
+ return await ipcRenderer.invoke("open-backend-crash-report");
+ },
+ onBackendProcessExited: function (callback) {
+ if (typeof callback !== "function") {
+ return;
+ }
+ ipcRenderer.on("backend-process-exited", (_event, payload) => callback(payload));
+ },
});

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 18fe1472..f6f7fdad 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -4826,7 +4826,15 @@ class ReticulumMeshChat:
data,
"max_reconnect_tries",
)
- InterfaceEditor.update_value(interface_details, data, "fixed_mtu")
+ fixed_mtu_error = InterfaceEditor.apply_fixed_mtu(
+ interface_details,
+ data,
+ )
+ if fixed_mtu_error is not None:
+ return web.json_response(
+ {"message": fixed_mtu_error},
+ status=422,
+ )
if interface_type == "BackboneInterface":
# BackboneInterface supports two distinct configurations:

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 16e8b28a..39dd3cd7 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -10,9 +10,15 @@
:show-emergency="Boolean(appInfo?.emergency)"
:emergency-label="$t('app.emergency_mode_active')"
:show-ws-disconnected="showWsDisconnectedBanner"
- :ws-disconnected-label="`${$t('app.backend_disconnected')} · ${wsDisconnectedDurationText}`"
+ :ws-disconnected-label="backendOfflineBannerLabel"
+ :show-backend-recovery-actions="showBackendRecoveryActions"
+ :backend-restarting="backendRestarting"
+ :restart-backend-label="$t('app.restart_backend')"
+ :view-backend-logs-label="$t('app.view_backend_logs')"
:show-ws-reconnected="wsReconnectedBanner"
:ws-reconnected-label="$t('app.backend_reconnected')"
+ @restart-backend="onRestartBackend"
+ @view-backend-logs="onViewBackendCrashReport"
/>
<RouterView v-if="$route.name === 'auth'" />
@@ -753,6 +759,9 @@ export default {
wsReconnectedBanner: false,
wsDisconnectTickTimer: null,
wsReconnectedHideTimer: null,
+ backendProcessExited: false,
+ backendExitCode: null,
+ backendRestarting: false,
identitySwitchDedupeHash: null,
identitySwitchDedupeAt: 0,
@@ -787,6 +796,24 @@ export default {
showWsDisconnectedBanner() {
return this.shellRunning && this.wsDisconnected && this.$route?.name !== "auth";
},
+ backendOfflineBannerLabel() {
+ const duration = this.wsDisconnectedDurationText;
+ const durationSuffix = duration ? ` · ${duration}` : "";
+ if (this.backendProcessExited) {
+ const code =
+ this.backendExitCode != null && this.backendExitCode !== "" ? ` (${this.backendExitCode})` : "";
+ return `${this.$t("app.backend_process_stopped")}${code}${durationSuffix}`;
+ }
+ return `${this.$t("app.backend_disconnected")}${durationSuffix}`;
+ },
+ showBackendRecoveryActions() {
+ return (
+ this.showWsDisconnectedBanner &&
+ this.backendProcessExited &&
+ ElectronUtils.isElectron() &&
+ typeof window.electron?.restartBackend === "function"
+ );
+ },
identitySidebarLabel() {
const raw = this.displayName;
const name = raw != null && String(raw).trim() !== "" ? String(raw).trim() : "";
@@ -864,6 +891,11 @@ export default {
this.startShellAuthWatch();
this.applyShellAppearance();
if (ElectronUtils.isElectron()) {
+ if (typeof window.electron.onBackendProcessExited === "function") {
+ window.electron.onBackendProcessExited((payload) => {
+ this.onBackendProcessExited(payload);
+ });
+ }
window.electron.onProtocolLink((url) => {
this.handleProtocolLink(url);
});
@@ -973,6 +1005,9 @@ export default {
this.wsDisconnectedAt = null;
this.wsDisconnectedDurationText = "";
this.wsReconnectedBanner = false;
+ this.backendProcessExited = false;
+ this.backendExitCode = null;
+ this.backendRestarting = false;
WebSocketConnection.destroy();
},
clearWsShellUiTimers() {
@@ -985,6 +1020,45 @@ export default {
this.wsReconnectedHideTimer = null;
}
},
+ onBackendProcessExited(payload = {}) {
+ if (!this.shellRunning) {
+ return;
+ }
+ this.backendProcessExited = true;
+ this.backendExitCode = payload?.code ?? null;
+ this.onWsShellDisconnected();
+ },
+ async onRestartBackend() {
+ if (!window.electron?.restartBackend) {
+ return;
+ }
+ this.backendRestarting = true;
+ try {
+ const result = await window.electron.restartBackend();
+ if (!result?.ok) {
+ ToastUtils.error(result?.error || this.$t("app.restart_backend_failed"));
+ return;
+ }
+ ToastUtils.info(this.$t("app.restart_backend_started"));
+ } catch {
+ ToastUtils.error(this.$t("app.restart_backend_failed"));
+ } finally {
+ this.backendRestarting = false;
+ }
+ },
+ async onViewBackendCrashReport() {
+ if (!window.electron?.openBackendCrashReport) {
+ return;
+ }
+ try {
+ const result = await window.electron.openBackendCrashReport();
+ if (!result?.ok) {
+ ToastUtils.error(result?.error || this.$t("app.view_backend_logs_failed"));
+ }
+ } catch {
+ ToastUtils.error(this.$t("app.view_backend_logs_failed"));
+ }
+ },
onWsShellDisconnected() {
if (!this.shellRunning) {
return;
@@ -1011,6 +1085,8 @@ export default {
this.wsDisconnected = false;
this.wsDisconnectedAt = null;
this.wsDisconnectedDurationText = "";
+ this.backendProcessExited = false;
+ this.backendExitCode = null;
if (this.wsDisconnectTickTimer != null) {
clearInterval(this.wsDisconnectTickTimer);
this.wsDisconnectTickTimer = null;

diff --git a/meshchatx/src/frontend/components/layout/AppShellBanners.vue b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
index be8d95cf..f4f15d7a 100644
--- a/meshchatx/src/frontend/components/layout/AppShellBanners.vue
+++ b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
@@ -14,11 +14,28 @@
<div
v-if="showWsDisconnected"
- class="relative z-100 bg-red-700 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-red-800/80"
+ class="relative z-100 bg-red-700 text-white px-4 py-3 text-center text-sm font-medium shadow-md border-b border-red-800/80"
role="status"
aria-live="polite"
>
- {{ wsDisconnectedLabel }}
+ <p>{{ wsDisconnectedLabel }}</p>
+ <div v-if="showBackendRecoveryActions" class="mt-2 flex flex-wrap items-center justify-center gap-2">
+ <button
+ type="button"
+ class="rounded-md bg-white/15 px-3 py-1 text-xs font-semibold hover:bg-white/25 disabled:opacity-60"
+ :disabled="backendRestarting"
+ @click="$emit('restart-backend')"
+ >
+ {{ restartBackendLabel }}
+ </button>
+ <button
+ type="button"
+ class="rounded-md bg-white/10 px-3 py-1 text-xs font-semibold hover:bg-white/20"
+ @click="$emit('view-backend-logs')"
+ >
+ {{ viewBackendLogsLabel }}
+ </button>
+ </div>
</div>
<div
v-if="showWsReconnected"
@@ -54,6 +71,22 @@ export default {
type: String,
default: "",
},
+ showBackendRecoveryActions: {
+ type: Boolean,
+ default: false,
+ },
+ backendRestarting: {
+ type: Boolean,
+ default: false,
+ },
+ restartBackendLabel: {
+ type: String,
+ default: "",
+ },
+ viewBackendLogsLabel: {
+ type: String,
+ default: "",
+ },
showWsReconnected: {
type: Boolean,
default: false,
@@ -63,5 +96,6 @@ export default {
default: "",
},
},
+ emits: ["restart-backend", "view-backend-logs"],
};
</script>

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 4fe76a61..d1463f43 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -310,7 +310,13 @@
"loading_overlay_subtitle": "Loading your chats and settings. This usually takes a few seconds.",
"emergency_mode_active": "Emergency Mode Active - Using in-memory database and restricted services.",
"backend_disconnected": "Disconnected from backend",
+ "backend_process_stopped": "Reticulum backend stopped",
"backend_reconnected": "Reconnected to backend",
+ "restart_backend": "Restart backend",
+ "restart_backend_started": "Restarting backend…",
+ "restart_backend_failed": "Could not restart backend",
+ "view_backend_logs": "View crash log",
+ "view_backend_logs_failed": "No crash log available",
"blackhole_integration_enabled": "Blackhole Integration",
"blackhole_integration_description": "Automatically blackhole identities at the Reticulum transport layer when banishing users in MeshChatX.",
"announce_limits": "Announce Limits",
@@ -853,6 +859,8 @@
"all_types": "All types",
"no_interfaces_found": "No interfaces found",
"no_interfaces_description": "Adjust your search or add a new interface.",
+ "fixed_mtu_hint": "Optional. If set, must be at least {min} bytes (Reticulum minimum MTU).",
+ "fixed_mtu_min": "Fixed MTU must be at least {min} bytes (Reticulum minimum).",
"restart_required": "Restart required",
"restart_description": "Reticulum MeshChatX must be restarted for any interface changes to take effect.",
"restart_now": "Restart now",

diff --git a/scripts/ci/ci-node-path.sh b/scripts/ci/ci-node-path.sh
index eb5b3987..ffbddbb6 100644
--- a/scripts/ci/ci-node-path.sh
+++ b/scripts/ci/ci-node-path.sh
@@ -1,2 +1,8 @@
#!/bin/sh
-export PATH="/usr/local/bin:$PATH"
+# Ensure /usr/local/bin (go-task, etc.) is on PATH without shadowing actions/setup-node.
+# ARM64 GitHub-hosted images may ship Node 20 in /usr/local/bin; prepending it breaks pnpm 11.
+case ":${PATH}:" in
+ *:/usr/local/bin:*) ;;
+ *) PATH="${PATH}:/usr/local/bin" ;;
+esac
+export PATH

diff --git a/scripts/ci/github-build-linux-release-assets.sh b/scripts/ci/github-build-linux-release-assets.sh
index 33ed490a..e80783ed 100644
--- a/scripts/ci/github-build-linux-release-assets.sh
+++ b/scripts/ci/github-build-linux-release-assets.sh
@@ -10,6 +10,20 @@ cd "$ROOT"
# shellcheck source=scripts/ci/ci-node-path.sh
. "$(dirname "$0")/ci-node-path.sh"
+require_node_min() {
+ _min_major="${1:-22}"
+ _ver="$(node -v 2>/dev/null || true)"
+ _major="${_ver#v}"
+ _major="${_major%%.*}"
+ if [ -z "$_major" ] || [ "$_major" -lt "$_min_major" ]; then
+ echo "Node.js ${_min_major}+ required (got: ${_ver:-unknown}); check PATH does not prefer /usr/local/bin over setup-node." >&2
+ command -v node >&2 || true
+ exit 1
+ fi
+}
+
+require_node_min 24
+
mkdir -p release-assets
HOST_ARCH="$(uname -m)"

diff --git a/scripts/patch-electron-builder-fs.cjs b/scripts/patch-electron-builder-fs.cjs
index ddd0ae48..473174d2 100644
--- a/scripts/patch-electron-builder-fs.cjs
+++ b/scripts/patch-electron-builder-fs.cjs
@@ -45,22 +45,18 @@ function patchBinDownload(source) {
}
if (!next.includes("__ebElectronDownloadCacheMode")) {
- const injectAfterGetRequire =
- /(const get_1 = require\("@electron\/get"\);)/;
+ const injectAfterGetRequire = /(const get_1 = require\("@electron\/get"\);)/;
if (injectAfterGetRequire.test(next)) {
next = next.replace(
injectAfterGetRequire,
`$1
-const __ebElectronDownloadCacheMode = get_1.ElectronDownloadCacheMode ?? ${JSON.stringify(cacheModeFallback)};`,
+const __ebElectronDownloadCacheMode = get_1.ElectronDownloadCacheMode ?? ${JSON.stringify(cacheModeFallback)};`
);
next = next.replace(
/get_1\.ElectronDownloadCacheMode\.ReadWrite/g,
- "__ebElectronDownloadCacheMode.ReadWrite",
- );
- next = next.replace(
- /in get_1\.ElectronDownloadCacheMode/g,
- "in __ebElectronDownloadCacheMode",
+ "__ebElectronDownloadCacheMode.ReadWrite"
);
+ next = next.replace(/in get_1\.ElectronDownloadCacheMode/g, "in __ebElectronDownloadCacheMode");
changed = true;
}
}

diff --git a/tests/electron/backendProcess.test.js b/tests/electron/backendProcess.test.js
new file mode 100644
index 00000000..351da891
--- /dev/null
+++ b/tests/electron/backendProcess.test.js
@@ -0,0 +1,74 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createBackendProcessManager } from "../../electron/backendProcess.js";
+
+function createFakeChildProcess() {
+ const { EventEmitter } = require("node:events");
+ const proc = new EventEmitter();
+ proc.stdout = new EventEmitter();
+ proc.stdout.setEncoding = () => {};
+ proc.stderr = new EventEmitter();
+ proc.stderr.setEncoding = () => {};
+ proc.pid = 4242;
+ proc.exitCode = null;
+ proc.signalCode = null;
+ return proc;
+}
+
+describe("electron/backendProcess", () => {
+ let fakeProc;
+ let spawnMock;
+
+ beforeEach(() => {
+ fakeProc = createFakeChildProcess();
+ spawnMock = vi.fn(() => fakeProc);
+ });
+
+ it("keeps the shell open and notifies renderer when backend exits during app use", async () => {
+ const notifyRenderer = vi.fn();
+ const showCrashPage = vi.fn();
+ const manager = createBackendProcessManager({
+ log: vi.fn(),
+ getDefaultStorageDir: () => "/tmp/storage",
+ getDefaultReticulumConfigDir: () => "/tmp/reticulum",
+ getMainWindowPageKind: () => "app",
+ isQuiting: () => false,
+ notifyRenderer,
+ showCrashPage,
+ spawn: spawnMock,
+ });
+
+ manager.setUserProvidedArguments([]);
+ await manager.spawnBackend("/tmp/ReticulumMeshChatX", { backend: { ok: true, issues: [] } });
+
+ fakeProc.emit("exit", 255);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(notifyRenderer).toHaveBeenCalledWith("backend-process-exited", expect.objectContaining({ code: 255 }));
+ expect(showCrashPage).not.toHaveBeenCalled();
+ expect(manager.getRuntimeState().running).toBe(false);
+ expect(manager.getRuntimeState().lastExitCode).toBe(255);
+ });
+
+ it("opens the crash page when backend exits outside the main shell", async () => {
+ const notifyRenderer = vi.fn();
+ const showCrashPage = vi.fn();
+ const manager = createBackendProcessManager({
+ log: vi.fn(),
+ getDefaultStorageDir: () => "/tmp/storage",
+ getDefaultReticulumConfigDir: () => "/tmp/reticulum",
+ getMainWindowPageKind: () => "other",
+ isQuiting: () => false,
+ notifyRenderer,
+ showCrashPage,
+ spawn: spawnMock,
+ });
+
+ manager.setUserProvidedArguments([]);
+ await manager.spawnBackend("/tmp/ReticulumMeshChatX", { backend: { ok: true, issues: [] } });
+ fakeProc.emit("exit", 1);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(notifyRenderer).toHaveBeenCalled();
+ expect(showCrashPage).toHaveBeenCalledWith(expect.objectContaining({ code: 1 }));
+ });
+});

diff --git a/tests/electron/preload.test.js b/tests/electron/preload.test.js
index ccb77117..dac2a827 100644
--- a/tests/electron/preload.test.js
+++ b/tests/electron/preload.test.js
@@ -69,6 +69,26 @@ describe("electron/preload", () => {
expect(cb).toHaveBeenCalledWith("rns://x");
});
+ it("exposes backend recovery IPC helpers", async () => {
+ const exposeInMainWorld = vi.fn();
+ const invoke = vi.fn();
+ const on = vi.fn();
+ loadPreloadWithElectronMock({
+ contextBridge: { exposeInMainWorld },
+ ipcRenderer: { invoke, on },
+ });
+ const api = exposeInMainWorld.mock.calls[0][1];
+ invoke.mockResolvedValueOnce({ ok: true });
+ await expect(api.restartBackend()).resolves.toEqual({ ok: true });
+ expect(invoke).toHaveBeenCalledWith("restart-backend");
+
+ const cb = vi.fn();
+ api.onBackendProcessExited(cb);
+ const handler = on.mock.calls.find((c) => c[0] === "backend-process-exited")?.[1];
+ handler({}, { code: 255 });
+ expect(cb).toHaveBeenCalledWith({ code: 255 });
+ });
+
it("subscribes to log channel on load", () => {
const exposeInMainWorld = vi.fn();
const on = vi.fn();


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────